Skip to content

feat: rebuild in-app notifications as contained cards, round-square bot avatar - #29

Merged
boehlerlukas merged 11 commits into
masterfrom
feat/notification-card-redesign
Aug 17, 2026
Merged

feat: rebuild in-app notifications as contained cards, round-square bot avatar#29
boehlerlukas merged 11 commits into
masterfrom
feat/notification-card-redesign

Conversation

@boehlerlukas

@boehlerlukas boehlerlukas commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Ports the JavaScript SDK's notification redesign (GleapSDK/JavaScript-SDK#138) to the native Android overlay, so the three platforms render the same notification system.

Layout

  • Avatar moves inside the card; the speech-bubble drawable is gone.
  • Sender moves below the message as a meta line: Leonie • 5 minutes ago — the sender half truncates (its max width is computed against the measured time label), the timestamp never does, and the separator only renders when both halves exist.
  • Message text clamps at 2 lines.
  • All three card types (message / news / checklist) share one chrome via a common styledCard builder: full stack width, the project's container radius, CardView elevation for the shadow, a hairline stroke foreground (API 23+), and clipToOutline so the news cover squares off against the rounded top corners.

Widget-theme colors

The cards were hardcoded white/black. They now derive everything from the flow config, exactly like the web widget's injectStyledCSS: background from backgroundColor, contrast by YIQ (≥160 → dark text), muted text by shading the background (+100 dark / −120 light), radius from the newly-parsed borderRadius (× 0.8). GleapNotificationStyle centralizes the math. A dark-themed project now gets dark cards.

Bot avatar

A teammate stays circular; the bot gets a rounded square (max(2, round(round(borderRadius·0.4)·size/36)) → 7dp at the default 20 on the 32dp avatar) through a ViewOutlineProvider clip, driven by the new sender.isBot (GleapSDK/Server#1373). Payloads without the flag fall through to the circle, so this ships safely ahead of/behind the server.

Notification stack

More than one notification collapses into a stack: newest card in front, up to two older ones peeking above its top edge (9dp / 17dp, scaled 0.955 / 0.91, pivot at top-center), anything deeper hidden. The first tap expands it; any re-render collapses it again. Cards are bottom-anchored in a FrameLayout and placed purely via translationY/scale; the frame keeps the expanded height and is not clickable, so the empty area above a collapsed stack stays transparent to touches. A taller card behind (e.g. a news cover) is clipped to the front card's height with clipBounds (negative insets spare the shadow), so after peek and scale-back its bottom stays clear of the front card's rounded corners. The clip-chain (clipChildren/clipToPadding) is opened so peeks, shadows and the close-button overhang survive.

The cap rises from 2 to 4 kept notifications, and the container branches that sat flush against the screen edge gain the 20dp inset the old per-card margins used to provide.

Close button

Floats over the stack's top-right corner (−9dp overhang, mirrored in RTL via Gravity.END), on the widget background color with the contrast-tinted cross, elevated above the cards, riding along the stack's visual top as it expands/collapses.

Timestamps

ICU RelativeDateTimeFormatter (API 24+; the label is dropped below), localized off the widget language with a device-locale fallback. Reads sendAt rather than createdAt (a scheduled outbound is created long before delivery), collapses anything under a minute to "now", clamps future timestamps, and promotes units (90 minutes → "1 hour").

Fixes found along the way

  • A latent ConcurrentModificationException in addNotification: the news/checklist dedup iterated the message list while removeNotificationViewFromLayout mutated it (and then called iterator.remove() on top). Reproduced live when a real outbound news card met an injected one; the loop now collects first, removes after.

Testing

  • :gleap:compileDebugJavaWithJavac and :gleap:testDebugUnitTest green; demo app built and driven live on the API 36 emulator with injected payloads through the real addNotification path (plus a genuine production outbound news card that arrived alongside): single card, 4-card collapsed stack, tap-to-expand, card-tap opens the conversation and clears, bot rounded-square vs teammate circle, sender truncation, "now"/"5 minutes ago" labels. (Screenshots from the emulator run are with Lukas.)

🤖 Generated with Claude Code

Animation architecture (final, after live review)

  • Fixed-height stack viewport: the frame/container keeps one constant height; cards are bottom-anchored inside it and only the card animators ever move them. (Resizing the container per state re-anchored the bottom-pinned cards mid-animation — the deck rendered offset by the height delta and slid across the screen.)
  • Always-on, animated per-card clips (the web widget's animated clip-path, ported properly): generous insets past the body at rest — shadows stay alive — and the front card's height for collapsed cards behind; every transition animates the clip in lockstep with the card's motion, with the starting edge clamped to the body. The sweeping edge never falls below the front card's bottom: nothing can poke out of the stack or show a cut line, in any frame.
  • In-place entrances: a new card (first or onto a stack) materializes in its slot — fade plus a 0.97→1 scale, zero translation.
  • Choreographed arrivals: the previous cards animate back into their tuck (350ms material standard easing / iOS ease-out) while the new front fades in.
  • Shadow correctness on Android: elevation shadows ignore view alpha, so fading elements ramp their elevation from zero alongside the fade (cards and the close button); card elevation is 3dp with a lightened spot tint to match the iOS two-layer softness.

boehlerlukas and others added 11 commits August 17, 2026 09:10
…ot avatar

Ports the JavaScript SDK's notification redesign (GleapSDK/JavaScript-SDK#138)
to the native Android overlay:

- Avatar moves inside the card (no speech-bubble tail); sender + relative
  time render as a meta line below the 2-line-clamped message.
- The widget theme drives the look: background, contrast and muted text
  colors derive from the flow config's backgroundColor (YIQ + shade, same
  math as the web widget), the card radius from its new borderRadius field
  (GleapNotificationStyle centralizes the math).
- Bots get a rounded-square avatar via sender.isBot (Server#1373); absent
  flags fall through to the teammate circle.
- Multiple notifications collapse into a stack — newest in front, up to two
  peeks behind, deeper cards hidden; the first tap expands, re-renders
  collapse. Cards are bottom-anchored in a FrameLayout and placed purely via
  translationY/scale, with clipBounds trimming taller cards behind the front
  card's bottom edge.
- The close button floats over the stack's top corner (elevation keeps it
  above the cards; the container chain stops clipping so the overhang and
  shadows survive). The container branches that sat flush against the screen
  edge gain the 20dp inset the per-card margins used to provide.
- Cap rises from 2 to 4 kept notifications.
- Timestamps read sendAt (not createdAt) through ICU's
  RelativeDateTimeFormatter, localized by the widget language, 'now' under a
  minute, clamped for future timestamps, dropped entirely below API 24.
- Fixes a latent ConcurrentModificationException in addNotification: the
  news/checklist dedup iterated the message list while
  removeNotificationViewFromLayout mutated it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A clipped back card (e.g. a news cover) ended exactly at the front card's
bottom edge, so its white body showed through the transparent notches behind
the front card's rounded bottom corners. Clip to the front card's height in
card space instead — after the peek offset and scale-back the clipped bottom
lands above the front card's bottom, matching the web widget's clip math.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o button state

Three refinements from live review:

- Shadows: card elevation drops 6dp -> 4dp and the close button 8dp -> 6dp,
  with the spot shadow tinted to ~60% black on P+ — soft and airy like the
  web widget's two-layer shadow instead of the heavier stock elevation look.

- Arrivals are choreographed as one deck motion: the previous cards animate
  back into their tuck while the new card emerges scaled-back from the
  stack's front slot, instead of the old front snapping into place and the
  new card floating up from the empty space below the stack. The very first
  notification keeps the web-style slide-up fade, and re-adds after an
  overlay rebuild (activity change, reconnect) no longer replay the
  entrance at all.

- The container's constraints re-apply when the feedback button's state
  flips (setShowFab): the button settles asynchronously after the config
  loads, and a container built in that window was anchored to the bottom of
  the screen — a notification arriving then rendered below the button until
  the next rebuild relocated it, which read as the card animating up from
  the screen edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The arrival jank root cause: the stack frame was resized to the stack's
expanded height on every change. Cards are bottom-anchored, so the resize
re-based every card (and moved the container's top edge) while the arrival
animators were mid-flight — the whole deck rendered offset by the height
delta and visibly slid across the screen into place, reading as the new
card flying in from the bottom of the display.

The frame now keeps one fixed height (screen height, floor 1200dp), set
once at creation, so nothing ever re-bases: only the card animators move
cards. The container carries the same height explicitly in its constraints
— left at WRAP_CONTENT, ConstraintLayout measures it AT_MOST the parent and
the taller frame overflows past the container's bottom, pushing cards off
screen. The close button anchors to the stack's visual top computed from
the fixed height, and a newly added card is alpha-0 from the moment it
enters the hierarchy so it cannot flash at its resting position before the
entrance starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DecelerateInterpolator(2f) front-loads ~60% of the travel into the first
frames — on a long tuck (a tall news card taking the front from a short
message) the deck appeared to teleport and then drift. All stack motions
(arrival choreography, expand, entrance, close button) now share the
material standard-easing curve (0.4, 0, 0.2, 1) at 350ms, matching the
gentle read of the iOS ease-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two structural guarantees replace the per-animation fixes:

- The stack frame clips at its bottom edge permanently (generous side/top
  overscan for shadows and the close-button overhang, a small bottom
  allowance for the front card's shadow). No card, shadow or mid-animation
  overhang can ever draw below the stack — the feedback button underneath
  is untouchable by construction. Previously a tall card's unclipped body
  slid across the button during tuck/expand transitions, because the
  per-card clips only applied after the motion settled.

- Entrances materialize in place: the new front card (and the very first
  notification) fades in with a slight scale-up and zero translation, so an
  arrival can never read as a card traveling in from elsewhere on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The container-level clip guaranteed containment but guillotined the front
card's soft shadow at a straight line, and mid-animation a tall card's body
was visible (then bluntly cut) in the strip below the front card.

The web widget's model, ported properly: every card carries a clip at ALL
times — generous insets past the body in resting states (a visual no-op:
Android elevation shadows are outline-based and ignore clipBounds, so
nothing is ever cut), the front card's height for collapsed cards behind —
and every transition ANIMATES the clip in lockstep with the card's motion
(same duration, same curve, starting edge clamped to the card's body). The
sweeping edge mathematically never falls below the front card's bottom, so
a tall card can neither poke out below the stack mid-flight nor show a cut
edge: the clip line always hides behind the front card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Elevation shadows are drawn from the view's outline and ignore the view's
alpha — a card (or the close button) fading in cast its shadow at full
strength from the first frame, a short dark flicker under a still-invisible
element. The card's elevation and the close button's now ramp from zero in
sync with their fades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Elevation 4dp -> 3dp with a lighter spot tint (argb 115) — the barely-there
soft throw the iOS two-layer shadow has.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-syncs with the iOS SDK (which shipped 16.4.3 solo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@boehlerlukas
boehlerlukas merged commit dacb3a8 into master Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant